Skip to content

examples: add HyperLiquid-style perp DEX contracts#44

Draft
tiero wants to merge 17 commits into
masterfrom
feat/perp-dex-example
Draft

examples: add HyperLiquid-style perp DEX contracts#44
tiero wants to merge 17 commits into
masterfrom
feat/perp-dex-example

Conversation

@tiero

@tiero tiero commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

Adds two new example contracts under examples/perp/ demonstrating a HyperLiquid-style perpetual futures DEX built on Arkade.

Contracts

perp_position.ark — live position VTXO

Holds the combined collateral of both sides for the duration of the trade.

Function Who Description
close() Trader Exit at oracle mark price; PnL split between trader and exchange
liquidate() Anyone Permissionless liquidation when margin < maintenance; liquidator earns fee
addMargin() Trader Top up collateral without oracle
transferPosition() Trader Assign position to a new holder
fundingSettle() Exchange Roll accrued funding into initialMarginSats, update rate
forceClose() Exchange Risk-management close at oracle price

perp_offer.ark — maker order book entry VTXO

Pre-committed margin with a price range; filled non-interactively.

Function Who Description
fill() Taker Full fill within maker's [minPrice, maxPrice] range
partialFill() Taker Partial fill; leaves a smaller PerpOffer VTXO
cancel() Maker Reclaim margin at any time
update() Maker Adjust price range / expiry without redeploying

Design

  • Long/short encoded as isLong flag; short PnL = 2×initialMarginSats - markValueSats
  • Limit order semantics: fill() enforces minPrice <= oraclePrice <= maxPrice
  • Funding: follows StabilityVault model — fundingRatePerSec at 1e12 scale, applied to initialMarginSats
  • Permissionless liquidation with liquidationFeeBps reward to the liquidator
  • Partial fills leave a proportionally smaller PerpOffer VTXO, enabling order-book-style incremental fills
  • Single VTXO holds both sides' margin (totalCollateral = makerMargin + takerMargin)
  • Oracle model matches stability/* contracts: sha256(ticker || price || time), 600s freshness window

claude and others added 17 commits May 15, 2026 17:01
Translate the asset-flow and signature semantics from the LayerZero/USDT0
prototype Go scripts (layerzero-usdt0-arkade-demo/internal/scripts/builders.go)
into a four-contract Arkade suite under examples/layerzero/:

  - endpoint.ark         Endpoint state with receive() + send() transitions,
                         2-of-2 DVN attestation, receive marker mint,
                         send marker burn
  - oapp.ark             OApp state with receive() + send() transitions,
                         USDT0 mint/burn, marker consumption/emission
  - receive_marker.ark   Endpoint→OApp invocation marker pinned to the
                         OApp control singleton
  - send_marker.ark      OApp→Endpoint invocation marker pinned to the
                         Endpoint control singleton

Asset-level invariants from the Go spec map directly to OP_INSPECT*ASSET*
opcodes via tx.inputs[i].assets.lookup, tx.outputs[o].assets.lookup, and
tx.assetGroups.find(id).{delta,sumInputs,sumOutputs}. Contract continuation
uses the existing new ContractName(...) covenant. Packet-level invariants
(OP_INSPECTPACKET / OP_SUBSTR / OP_BIN2NUM / OP_INSPECTINPUTARKADESCRIPTHASH)
that the Arkade compiler does not yet expose are documented in each file and
delegated to the introspector runtime — see examples/layerzero/README.md
for the mapping table.

Also:
  - Register the layerzero project in the playground sidebar.
  - Add tests/layerzero_test.rs (14 tests) that pin the key invariants:
    DVN signature checks, marker mint/burn via group sums, state continuation
    via OP_INSPECTOUTPUTSCRIPTPUBKEY, and control-asset singleton checks in
    the markers.
Brings the Arkade compiler in line with the canonical introspector opcode
set (https://github.com/ArkLabsHQ/introspector). Adds opcode constants for
everything the introspector documents, then wires grammar/parser/compiler/
typechecker for the subset needed by the LayerZero / USDT0 demo so the
contracts in examples/layerzero/ can express packet-level invariants
natively instead of delegating them to the runtime.

New language surface
--------------------
  tx.packet(packetType)            → OP_INSPECTPACKET <1> OP_EQUALVERIFY
  tx.inputs[i].packet(packetType)  → OP_INSPECTINPUTPACKET <1> OP_EQUALVERIFY
  substr(data, off, size)          → OP_SUBSTR
  cat(a, b)                        → OP_CAT
  bin2num(data)                    → OP_BIN2NUM
  num2bin(value, size)             → OP_NUM2BIN
  size(data)                       → OP_SIZE OP_NIP
  tx.inputs[i].arkadeScriptHash    → OP_INSPECTINPUTARKADESCRIPTHASH
  tx.inputs[i].arkadeWitnessHash   → OP_INSPECTINPUTARKADEWITNESSHASH
  tx.id                            → OP_TXID

Files
-----
  src/opcodes/mod.rs       add OP_INSPECTPACKET, OP_INSPECTINPUTPACKET,
                           OP_INSPECTINPUT(ARKADESCRIPTHASH|ARKADEWITNESSHASH),
                           OP_TXID, OP_CAT, OP_SUBSTR, OP_LEFT, OP_RIGHT,
                           OP_BIN2NUM, OP_NUM2BIN, OP_SIZE, OP_EQUALVERIFY,
                           OP_NUMEQUALVERIFY, OP_SWAP, plus the bitwise and
                           extra-arithmetic opcodes listed by the introspector
                           README so they're available to future emission paths.
  src/parser/grammar.pest  new rules: substr_func, cat_func, bin2num_func,
                           num2bin_func, size_func, packet_inspect,
                           input_packet_inspect; new properties on
                           tx_introspection (id) and input_introspection
                           (arkadeScriptHash, arkadeWitnessHash).
  src/models/mod.rs        new Expression variants Substr, Cat, Bin2Num,
                           Num2Bin, SizeOf, PacketInspect, InputPacketInspect.
  src/parser/mod.rs        parse functions and dispatch entries for primary
                           and complex (require-context) expressions.
  src/compiler/mod.rs      emission in both generate_expression_asm and
                           emit_expression_asm; introspection-detection
                           updated so the new variants force the N-of-N
                           exit-path policy.
  src/typechecker/mod.rs   infer Bytes / Uint64Le / Int / Bytes32 for the
                           new variants and new introspection properties.

Tests
-----
  tests/packet_primitives_test.rs (10 tests) pins each new primitive to
  its canonical opcode and verifies emission shape (e.g. tx.packet asserts
  presence via "OP_1 OP_EQUALVERIFY"; size() drops the source bytes via
  OP_NIP). Full suite: 136 passed, 0 failed (was 126).
… rewrite

Round-2 follow-up to the introspector-primitive commit. Extends the grammar
so byte-producing primitives can flow into the existing comparison rules,
then rewrites the four LayerZero / USDT0 contracts to express the full
Go-script semantics natively instead of delegating packet-level checks to
the runtime.

Grammar / parser
----------------
  - new comparison shape `byte_expr_comparison`:
      (sha256|substr|cat|bin2num|num2bin|size) <op> (<rhs>)
    where <rhs> is another such term, a tiny byte_expr_arith
    (e.g. `group.sumOutputs + bin2num(substr(...))`), an identifier,
    or a number literal.
  - hash_comparison RHS broadened from `identifier` to also accept
    substr/cat/num2bin, so `sha256(substr(...)) == substr(...)` parses.
    Legacy `sha256(x) == y` (htlc) still hits the fast-path HashEqual.
  - asset_lookup_comparison, group_property_comparison, and
    group_property_arith_expr now accept bin2num(...) on the RHS, so
    contracts can balance an asset delta against a packet field.
  - input_introspection_comparison / output_introspection_comparison now
    accept substr(...) on the RHS, so LayerZero OApp.receive() can pin
    the recipient output's x-only key to a CreditMessage byte slice.
  - byte_value rule lets substr / cat / bin2num / size accept packet
    introspection and input/output introspection results as their byte
    argument (recursive PEG, terminates on terminals).
  - new Expression::Sha256 variant for inline hashing inside comparisons.

Compiler
--------
  - emit OP_PUSHCURRENTINPUTINDEX for `this.activeInputIndex` and
    OP_INPUTBYTECODE for `this.activeBytecode` (was placeholder before).
  - Expression::Sha256 emits `<data> OP_SHA256`.

LayerZero contracts (now packet-native)
---------------------------------------
  endpoint.ark
    - Endpoint state v1 / size 183, LzReceive v1 / size 219, DVN
      attestation v1 / size 228 — checked with size(tx.packet(t)) and
      substr(tx.packet(t), 0, 1) == 1.
    - Route fields (endpointID, oappID, remoteEID, remoteOApp) and DVN
      pubkeys pinned via substr(tx.packet(EndpointState), off, len) ==
      constructor_param.
    - DVN attested-hash binding via
        sha256(substr(tx.packet(LzReceive), 1, 140))
          == substr(tx.packet(DvnAttestation), 1, 32)
      and checkSigFromStackVerify over both DVNs.
    - Embedded CreditMessage hash binding
        sha256(substr(LzReceive, 145, 74)) == substr(LzReceive, 109, 32).
    - Receive marker pinned to `new ReceiveMarker(receiveMarkerScriptHash,
      oappCtrlAssetId, exit)`.
    - send(): LzSend v1 / size 181, OAppSendInvocation read via
      tx.inputs[1].packet(20), per-field invocation↔LzSend equality, and
      LzSend.GUID = sha256(invocation).

  oapp.ark
    - receive(): reads LzReceive from tx.inputs[0].packet(17), pins
      recipient output's scriptPubKey to substr(packet, 147, 32),
      credits USDT0 via usdt0Group.delta == bin2num(substr(packet,179,8)).
    - send(): emits OAppSendInvocation, burns USDT0 by
      usdt0Group.sumInputs == sumOutputs + bin2num(substr(packet, 103, 8))
      and pins the send marker output to
      `new SendMarker(sendMarkerScriptHash, endpointCtrlAssetId, exit)`.

  receive_marker.ark / send_marker.ark
    - this.activeInputIndex == 0/1 (OP_PUSHCURRENTINPUTINDEX equality).
    - tx.inputs[stateIdx].arkadeScriptHash == oappReceive / endpointSend
      ScriptHash (OP_INSPECTINPUTARKADESCRIPTHASH).
    - Control-asset singleton defense-in-depth as before.

Tests
-----
  - tests/layerzero_test.rs updated for the new contract shapes: DVN
    sigs verified via OP_CHECKSIGFROMSTACKVERIFY; receive uses
    OP_INSPECTPACKET / OP_SUBSTR / OP_SHA256; oapp.receive() uses
    OP_INSPECTINPUTPACKET and pins recipient pkScript; new
    `test_marker_contracts_use_input_arkade_script_hash`.
  - Full suite: 138 passed, 0 failed (was 136).

Also refreshes examples/layerzero/*.json artifacts and rewrites the
folder README to document the on-chain enforcement table.
OApp.send() carried `checkSig(ownerSig, ownerPk)` where `ownerPk` was a
function parameter — anyone calling send() could pass any keypair and a
valid signature for it, so the check authenticated no fixed identity. The
Go reference (BuildOAppSendScript) has no such check.

Authority for OApp.send() comes from:
  - the OApp control singleton on the spent state input (only the real
    OApp state holds it),
  - per-UTXO USDT0 input scripts (each owner signs their own input), and
  - either the Arkade server cosign (cooperative, serverVariant=true) or
    the exit CSV (fallback, serverVariant=false) — both added by the
    compiler automatically.

Also removes the now-orphaned `require(amount > 0)` line — the amount is
read from the OAppSendInvocation packet via bin2num(substr(...)), not a
witness.

Test update: replaces "must contain OP_CHECKSIG" with the stronger
invariant "the only OP_CHECKSIG in the server variant is the trailing
server cosign — no contract-level owner sig precedes <SERVER_KEY>".

Suite: 138 passed, 0 failed.
Newer rustc (1.94.x on CI ubuntu-latest) rejects what older versions
accepted: `cli.input.expect(...)` partially moves `cli.input`, then a
later `&cli` borrow of the whole struct fails with E0382. Cloning the
PathBuf (small, only a few bytes overhead) keeps `cli` intact for the
subsequent borrows.

Pre-existing bug surfaced by the toolchain upgrade — the
arkade-bindgen subcrate was unchanged by the LayerZero work, but CI
runs `cargo test --verbose` from the workspace root which compiles
all members. Locally with `cargo test` (single-package), bindgen was
never re-compiled so the issue stayed latent.

Workspace test suite: 158 passed, 0 failed.
PR #25 (now on master) added tests/compilation_roundtrip_test.rs which
sweeps every examples/**/*.ark file and asserts each function variant
(serverVariant=true and =false) has a non-empty witnessSchema. CI was
failing on the merge with master because OApp / ReceiveMarker /
SendMarker had no constructor pubkeys and no function signature
parameters, producing an empty witnessSchema for the exit variant:
just `<exit> OP_CHECKSEQUENCEVERIFY OP_DROP`.

Without a constructor pubkey, the unilateral exit path is effectively
"anyone may force-spend after the CSV timelock" — which is broken: any
party could force-recover stuck OApp / marker state and produce a
continuation transaction by themselves.

Fix: add `pubkey operatorPk` to OApp, ReceiveMarker, SendMarker, and
Endpoint constructors. The operator is the off-chain LayerZero / USDT0
relay entity and is the only N-of-N exit-witness participant. The
cooperative server-cosigned path is unchanged: DVN attestations + packet
introspection + OApp control singleton do all the authorisation work,
exactly as the Go reference (BuildOAppReceiveScript / BuildOAppSendScript)
specifies — operatorPk does NOT appear in any function body.

Endpoint already had dvn0Pk + dvn1Pk in its constructor (so its exit
witness was non-empty), but operatorPk is added there too for symmetry —
the same operator can recover stuck Endpoint state, and Endpoint now
passes operatorPk through to `new ReceiveMarker(...)` so all four
contracts share one operator identity.

Locally:
  cargo test                                            # 138/138 (was 138/138)
  git merge --no-commit --no-ff origin/master &&
    cargo test --test compilation_roundtrip_test        # now passes
When a contract uses introspection and has no constructor- or function-
supplied pubkeys, the exit-path N-of-N CHECKSIG chain is empty, so the
emitted exit script is just `<exit> OP_CHECKSEQUENCEVERIFY OP_DROP` and
the witnessSchema is empty. That means "anyone may force-spend after the
CSV timelock" — a broken unilateral exit shape.

Fix the issue at the compiler level rather than asking every contract
author to declare a placeholder pubkey:

  asm           when all_pubkeys.is_empty() and the exit variant is being
                generated, emit
                  <OPERATOR_KEY> <operatorSig> OP_CHECKSIG
                in front of the CSV. Same auto-injection pattern as the
                <SERVER_KEY> placeholder used for the cooperative path.
  witnessSchema add `operatorSig` (signature, schnorr-64).
  function ABI  push an `operatorSig` FunctionInput so SDK bindgen
                surfaces it as a required witness.
  require       emit `nOfNMultisig` with the message
                "operator signature required (auto-injected exit fallback)".

The placeholder `<OPERATOR_KEY>` is resolved by the runtime / wallet
exactly like `<SERVER_KEY>` is — `.ark` source never mentions it.

Contracts that already have constructor pubkeys (htlc, fuji_safe,
nft_mint, price_beacon, …) are unaffected: the empty-pubkey branch is
not taken, so their exit asm and witnessSchema are byte-identical to
before.

Reverts the previous LayerZero workaround commit ("add operatorPk to
constructor"). The four LayerZero contracts are back to their clean
shape and now compile through the master `compilation_roundtrip_test`
because each variant has a non-empty witnessSchema (cooperative:
serverSig; exit: operatorSig).

Local: 138 passed, 0 failed. Merge-with-master: 227 passed, 0 failed.
Per CLAUDE.md and the compiler spec:
  - <SERVER_KEY> is auto-injected on the COOPERATIVE path; that's the only
    auto-injected key.
  - The UNILATERAL exit path is "N-of-N CHECKSIG over the sum of all
    pubkeys in the constructor". When that sum is zero (no constructor
    pubkeys), the exit path collapses to pure CSV — an empty witness is
    the intended, correct shape for a fully-permissionless contract.

PR #25's compilation_roundtrip_test asserted `!witness_schema.is_empty()`
unconditionally on every variant, which baked in an implicit assumption
that every contract has at least one constructor pubkey. That broke for
the new permissionless LayerZero markers + OApp.send / OApp.receive,
which intentionally have no signer.

Changes:

  tests/compilation_roundtrip_test.rs
    Tighten the assertion so only the cooperative variant must have a
    non-empty witnessSchema (at minimum the auto-injected serverSig).
    Exit variants may be empty when there are no constructor pubkeys.
    Docstring updated with the rationale and a pointer to CLAUDE.md.

  src/validator/mod.rs
    Stop emitting the "empty witnessSchema" warning on exit variants for
    the same reason. The validator now warns only when the cooperative
    variant is missing serverSig (a real compiler bug).

LayerZero contracts revert to their natural shape:

  Endpoint (has dvn0Pk + dvn1Pk in ctor)  exit = 2-of-2 over DVN keys + CSV
  OApp                                    exit = pure CSV
  ReceiveMarker                           exit = pure CSV
  SendMarker                              exit = pure CSV

This commit also reverts ce369c4aa (auto-injecting <OPERATOR_KEY> on the
exit path) — that was a misread of the spec; the operator/server key is
only on the cooperative path, never the exit one. See the preceding
revert commit (18a407e).

Local: 227 passed, 0 failed (full merged-with-master suite).
…ntracts-spec-OdxcF

# Conflicts:
#	playground/main.js
#	src/compiler/mod.rs
Two correctness fixes plus README clarifications, from Arkana's round-4
review.

1. OApp.receive() recipient pkScript: substr(packet, 145, 34)
   The Arkade introspector returns the full scriptPubKey as a single
   bytes value (docs/arkade-primitives-spec.md Phase 7 — "outScript(bytes)"),
   not the (program, version) two-item split that some Liquid-style
   references describe. For a P2TR output that's 34 bytes (0x5120 tag
   + 32-byte x-only key). The old check compared 34 bytes against a
   32-byte substr of the CreditMessage, which would never have been
   equal. Switching to substr(packet, 145, 34) matches the full P2TR
   scriptPubKey including the tag, so the USDT0 credit actually lands
   at the recipient committed by the inbound message. Added an inline
   comment pointing at the spec reference.

2. Endpoint.receive() witness signature inputs
   `attestedHash`, `dvn0Sig`, and `dvn1Sig` were referenced inside the
   function body but not declared as function parameters, so they were
   emitted as <placeholder>s with no corresponding witnessSchema entry —
   the cooperative script would have been unsatisfiable. Added them to
   the function signature so the compiler registers them in the
   witnessSchema. The body's existing checks already pin attestedHash
   to sha256(LzReceive[1..141]) and to DvnAttestation[1..33], so the
   prover-supplied value remains tightly bound to canonical state; the
   same pattern as htlc.ark's `preimage` and fuji_safe.ark's
   `currentPrice` witnesses.

README updates
  - Document the "prover supplies witness, contract pins it on chain"
    convention used by attestedHash + DVN sigs.
  - Document the bytes32 _txid/_gidx decomposition rule (only ids fed
    to assets.lookup / assetGroups.find are split; pass-through ids
    stay as a single bytes32) — answers Arkana's third question about
    oappCtrlAssetId appearing un-split in Endpoint's constructor.
  - Expand the nonce-monotonicity note with the off-chain safety net:
    DVN replay isn't possible because each DVN signs over the LzReceive
    header (which includes the inbound nonce), so a tampered nonce
    would require a fresh DVN attestation honest DVNs won't produce.

Suite: 227 passed, 0 failed (merged with master).
The (Expression::Property, "==", Expression::Literal) fast path in
generate_comparison_asm was emitting `<lhs> OP_EQUAL <rhs>`, but
Bitcoin script requires push-left, push-right, then OP_EQUAL. The
existing master examples never hit this branch (their comparisons go
through emit_binary_op_asm or dedicated rules like time_comparison /
group_property_comparison, all of which emit the correct left-right-op
order), so the bug was dormant.

The new LayerZero marker contracts use `this.activeInputIndex == 0`,
which is the first real consumer of this fast path. Flipping the order
makes ReceiveMarker.consume() and SendMarker.consume() execute as
intended:

  before: OP_PUSHCURRENTINPUTINDEX OP_EQUAL 0
  after:  OP_PUSHCURRENTINPUTINDEX 0 OP_EQUAL

Left untouched: the sibling broken-order branches in the same match
(Variable==Variable, Variable>=Variable, Property>=Literal, etc.).
They remain dormant under the existing example corpus; touching them
would expand the diff far beyond the LayerZero PR scope. Filed mentally
as a follow-up cleanup.

Caught by CodeRabbit (round 5).
- generate_comparison_asm emitted `left OP_EQUAL right` for
  `Property == Literal`; reorder to `left right OP_EQUAL` and treat the
  `== true` dummy as a bare introspection push (no spurious OP_EQUAL).
- parse_byte_expr_term routed sha256()'s additive_expr child through a
  per-rule match that always fell through to a Property placeholder;
  use parse_additive_expr so sha256(substr(...)) emits inline opcodes.
- Add regression tests; regenerate affected example ASM.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add two new example contracts under examples/perp/:

- perp_position.ark: live perpetual position VTXO with close,
  liquidate, addMargin, transferPosition, fundingSettle, forceClose

- perp_offer.ark: maker order book entry VTXO with fill,
  partialFill, cancel, update

Key design points:
- Long/short encoded as isLong flag; short PnL = 2×initial - markValue
- Limit order semantics: fill enforces oracle price within [min,max] range
- Funding follows StabilityVault model: fundingRatePerSec at 1e12 scale
- Permissionless liquidation with liquidationFeeBps reward
- Partial fills leave a smaller PerpOffer VTXO (order book semantics)
- Both sides margin pooled in a single PerpPosition VTXO
- Oracle model matches stability/* contracts: sha256(ticker||price||time)
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 49f04a8e-2ccd-4729-95f9-bd00f2773cfe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/perp-dex-example

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Playground Preview

A live preview of this PR's playground is available at:
https://arkade-os.github.io/compiler/pr-previews/pr-44/

Built from commit fc32ae8d2a2055976a000c4b330a1d7919f56bba · Workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants